SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
10.8 KB · 217 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { ArrowRight, Paperclip, Swords } from "lucide-react";5import { getPublicArenaShare } from "@/lib/arena/service";6import type { ArenaShareSnapshot } from "@/lib/arena/export";7import { Logo } from "@/components/brand/logo";8import { ProviderIcon } from "@/components/brand/provider-icon";9import { Badge } from "@/components/ui/badge";10import { Button } from "@/components/ui/button";11import { SimpleMarkdown } from "@/components/markdown/simple-markdown";12import { BlindNotice, SharedArenaView } from "@/components/arena/shared-arena-view";1314export const dynamic = "force-dynamic";1516function isRecord(v: unknown): v is Record<string, unknown> {17  return typeof v === "object" && v !== null;18}1920/** Defensive parse of the frozen snapshot written by `shareArenaSession`. */21function parseSnapshot(raw: unknown): ArenaShareSnapshot | null {22  if (!isRecord(raw) || typeof raw.prompt !== "string" || !Array.isArray(raw.responses)) return null;23  const responses = raw.responses.filter(isRecord).map((r, i) => ({24    id: typeof r.id === "string" ? r.id : `r${i}`,25    modelKey: typeof r.modelKey === "string" ? r.modelKey : "",26    provider: typeof r.provider === "string" ? r.provider : (typeof r.modelKey === "string" ? r.modelKey.split("/")[0] : ""),27    displayName: typeof r.displayName === "string" ? r.displayName : typeof r.modelKey === "string" ? r.modelKey.split("/").slice(1).join("/") : "Model",28    status: typeof r.status === "string" ? r.status : "complete",29    content: typeof r.content === "string" ? r.content : "",30    reasoning: typeof r.reasoning === "string" ? r.reasoning : null,31    error: isRecord(r.error) && typeof r.error.message === "string" ? { code: String(r.error.code ?? "ERROR"), message: r.error.message } : null,32    ttftMs: typeof r.ttftMs === "number" ? r.ttftMs : null,33    latencyMs: typeof r.latencyMs === "number" ? r.latencyMs : null,34    costUsd: typeof r.costUsd === "number" ? r.costUsd : null,35    usage: isRecord(r.usage) ? (r.usage as ArenaShareSnapshot["responses"][number]["usage"]) : null,36    criteriaWon: Array.isArray(r.criteriaWon) ? r.criteriaWon.filter((c): c is string => typeof c === "string") : [],37  }));38  const models = Array.isArray(raw.models) ? raw.models.filter(isRecord).map((m) => ({ key: String(m.key ?? ""), provider: String(m.provider ?? ""), displayName: String(m.displayName ?? m.key ?? "") })) : responses.map((r) => ({ key: r.modelKey, provider: r.provider, displayName: r.displayName }));39  const votes = Array.isArray(raw.votes) ? raw.votes.filter(isRecord).map((v) => ({ criterion: String(v.criterion ?? ""), label: String(v.label ?? v.criterion ?? ""), modelKey: String(v.modelKey ?? ""), responseId: String(v.responseId ?? "") })) : [];40  const w = isRecord(raw.winner) ? raw.winner : null;41  const winner =42    w && typeof w.modelKey === "string"43      ? {44          modelKey: w.modelKey,45          responseId: String(w.responseId ?? ""),46          criteriaWon: Array.isArray(w.criteriaWon) ? w.criteriaWon.filter((c): c is string => typeof c === "string") : [],47          tieBreak: w.tieBreak === "fastest" ? ("fastest" as const) : w.tieBreak === "order" ? ("order" as const) : null,48          deltas: isRecord(w.deltas) ? { costUsd: num(w.deltas.costUsd), ttftMs: num(w.deltas.ttftMs), latencyMs: num(w.deltas.latencyMs), outputTokens: num(w.deltas.outputTokens), others: num(w.deltas.others) ?? 0 } : { costUsd: null, ttftMs: null, latencyMs: null, outputTokens: null, others: 0 },49        }50      : null;51  return {52    version: 1,53    prompt: raw.prompt,54    systemPrompt: typeof raw.systemPrompt === "string" ? raw.systemPrompt : null,55    blind: raw.blind === true,56    attachmentCount: typeof raw.attachmentCount === "number" ? raw.attachmentCount : 0,57    parameters: isRecord(raw.parameters) ? raw.parameters : {},58    createdAt: typeof raw.createdAt === "string" ? raw.createdAt : new Date().toISOString(),59    models,60    responses,61    votes,62    winner,63  };64}6566function num(v: unknown): number | null {67  return typeof v === "number" && Number.isFinite(v) ? v : null;68}6970async function getShareSafe(id: string) {71  if (!id || id.length > 128 || !/^[\w-]+$/.test(id)) return null;72  try {73    const row = await getPublicArenaShare(id);74    if (!row) return null;75    const snapshot = parseSnapshot(row.snapshot);76    return snapshot ? { row, snapshot } : null;77  } catch {78    return null;79  }80}8182function title(s: ArenaShareSnapshot): string {83  const names = s.models.map((m) => m.displayName);84  return names.length <= 2 ? names.join(" vs ") : `${names.slice(0, 2).join(" vs ")} + ${names.length - 2} more`;85}8687export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {88  const { id } = await params;89  const share = await getShareSafe(id);90  return {91    title: share ? `${title(share.snapshot)} — Arena comparison` : "Shared Arena comparison",92    description: share ? share.snapshot.prompt.slice(0, 160) : "This shared comparison is unavailable.",93    robots: { index: false, follow: false, nocache: true },94    openGraph: share ? { title: `${title(share.snapshot)} — PolyLLM Arena`, description: share.snapshot.prompt.slice(0, 200), type: "article" } : undefined,95  };96}9798export default async function SharedArenaPage({ params }: { params: Promise<{ id: string }> }) {99  const { id } = await params;100  const share = await getShareSafe(id);101  if (!share) notFound();102  const s = share.snapshot;103  const created = new Date(s.createdAt);104  const params_ = Object.entries(s.parameters).filter(([, v]) => v !== undefined && v !== null);105106  return (107    <div className="flex min-h-dvh flex-col">108      <header className="glass sticky top-0 z-40 border-b border-border">109        <div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 px-4 sm:px-6">110          <Link href="/" className="flex items-center gap-2.5 rounded-md" aria-label="PolyLLM home">111            <Logo size={22} />112            <span className="hidden text-[13px] text-fg-muted sm:inline">· Shared Arena comparison</span>113          </Link>114          <Button asChild size="sm">115            <Link href="/app/arena">116              Try the Arena117              <ArrowRight />118            </Link>119          </Button>120        </div>121      </header>122123      <main className="mx-auto w-full max-w-6xl flex-1 px-4 pb-16 pt-6 sm:px-6 sm:pt-10">124        <section className="border-b border-border pb-5">125          <p className="flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] text-fg-subtle">126            <Swords className="size-3.5" /> Arena · {s.models.length} model{s.models.length === 1 ? "" : "s"}127          </p>128          <h1 className="mt-2 text-balance text-xl font-semibold leading-tight tracking-tight sm:text-2xl">{title(s)}</h1>129          <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-[13px] text-fg-muted">130            <time dateTime={created.toISOString()}>{created.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</time>131            <span aria-hidden>·</span>132            <ul className="flex flex-wrap gap-1.5" aria-label="Models compared">133              {s.models.map((m) => (134                <li key={m.key}>135                  <Badge>136                    <ProviderIcon provider={m.provider} size={12} />137                    {m.displayName}138                  </Badge>139                </li>140              ))}141            </ul>142            {s.blind ? <BlindNotice /> : null}143          </div>144        </section>145146        <section className="mt-6 space-y-3" aria-label="Prompt">147          <div className="flex justify-end">148            <div className="max-w-full rounded-2xl rounded-br-md bg-bg-muted px-4 py-2.5 text-[15px] leading-7 sm:max-w-[80%]">149              <p className="sr-only">Prompt:</p>150              <SimpleMarkdown>{s.prompt}</SimpleMarkdown>151            </div>152          </div>153          {s.systemPrompt || s.attachmentCount || params_.length ? (154            <details className="text-[12.5px] text-fg-muted">155              <summary className="cursor-pointer select-none">Settings</summary>156              <div className="mt-2 space-y-2 rounded-lg border border-border bg-bg-subtle/60 px-3 py-2">157                {s.systemPrompt ? (158                  <p>159                    <span className="font-medium text-fg">System prompt:</span> {s.systemPrompt}160                  </p>161                ) : null}162                {s.attachmentCount ? (163                  <p className="inline-flex items-center gap-1.5">164                    <Paperclip className="size-3.5" /> {s.attachmentCount} attachment{s.attachmentCount === 1 ? "" : "s"} (not published)165                  </p>166                ) : null}167                {params_.length ? (168                  <ul className="flex flex-wrap gap-1.5">169                    {params_.map(([k, v]) => (170                      <li key={k} className="rounded-md border border-border bg-bg px-1.5 py-0.5 font-mono text-[11px]">171                        {k}: {typeof v === "object" ? JSON.stringify(v) : String(v)}172                      </li>173                    ))}174                  </ul>175                ) : null}176              </div>177            </details>178          ) : null}179        </section>180181        <section className="mt-6" aria-label="Responses">182          <SharedArenaView snapshot={s} />183        </section>184185        <aside className="mt-14 rounded-2xl border border-border bg-bg-elevated p-6 text-center shadow-sm sm:p-8">186          <p className="text-lg font-semibold tracking-tight">Compare models with your own keys</p>187          <p className="mx-auto mt-2 max-w-md text-[14px] leading-6 text-fg-muted">PolyLLM Arena runs one prompt through up to four models from OpenAI, Anthropic, Google, xAI, Mistral, DeepSeek, Kimi, OpenRouter and Cerebras — live, with real latency and cost.</p>188          <div className="mt-5 flex flex-col items-center justify-center gap-2 sm:flex-row">189            <Button asChild size="lg" className="w-full sm:w-auto">190              <Link href="/signup">191                Create a free account192                <ArrowRight />193              </Link>194            </Button>195            <Button asChild size="lg" variant="ghost" className="w-full sm:w-auto">196              <Link href="/">Learn more</Link>197            </Button>198          </div>199        </aside>200      </main>201202      <footer className="border-t border-border py-6 text-center text-xs text-fg-subtle">203        <p>204          Frozen snapshot shared by a PolyLLM user. Costs are estimates from list prices; model output can be wrong.{" "}205          <Link href="/privacy" className="underline-offset-4 hover:text-fg hover:underline">206            Privacy207          </Link>208          {" · "}209          <Link href="/terms" className="underline-offset-4 hover:text-fg hover:underline">210            Terms211          </Link>212        </p>213      </footer>214    </div>215  );216}217